Skip to content

fix(agent-core-v2): fold UserPromptSubmit hook output into the prompt message - #4081

Merged
sailist merged 4 commits into
mainfrom
feat-273-09-28-hook-result-prompt-part
Sep 29, 2026
Merged

sailist merged 4 commits into
mainfrom
feat-273-09-28-hook-result-prompt-part

Conversation

@sailist

@sailist sailist commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Requirement or Bug

Resolve #4032

Supersedes #4078(评审历史见该 PR)。

Bug Reproduction Steps

See linked issue(2.1.0/2.1.1 可复现:配置 UserPromptSubmit 钩子的会话里,轮次整体错位一位,最后一条消息看起来永远没有回复,刷新后恢复)。

Root Cause

引擎在 UserPromptSubmit 钩子返回文本后,把它作为一条独立的 user 角色消息(origin: hook_result)追加到真实用户消息之前,上下文序列变成 [hook 消息][用户消息][回复],user 单元翻倍。渲染层的轮次配对依赖「一个 user 单元配一段 assistant 输出」的不变量,任何不消费 origin 的配对路径都会因多出的 user 单元而整体错位一位。排查确认数据层(wire、fold、live store、投影)完整且对齐,错位只来自不消费 hook origin 的渲染路径。

这是根本修复而非绕过:让「一条 prompt 一条 user 消息」在结构上成立——钩子注入文本以带 meta 标记的 content part 并入 user prompt 消息,不再产生第二条 user 消息,轮次配对在结构上不存在错位可能,不再依赖每条渲染路径自觉遵守 origin 约定。

Code Changes

三个提交,按主题分六块:

1. content part 增加 meta 能力(agent-core-v2 + transcript)

// packages/agent-core-v2/src/human/llm/message.ts
interface TextPart {
  type: 'text';
  text: string;
  meta?: { source?: string; contentType?: string };  // 新增,仅 text 类型
}

wire schema(historySchema.ts)以 z.record 形态接受 meta(通用字典,前向兼容);packages/transcript 的 HistoryContentPart text 变体同步(browser-safe 纯类型)。meta 不下发 LLM provider(adapter 只读 type/text)。

2. 钩子注入合并进 prompt 消息(gate 单次落盘)

 runPromptGate
   onBeforeSubmitPrompt
-    hook 服务: context.append(独立 hook_result user 消息)   # user 单元翻倍
+    hook 服务: ctx.hookParts.push(meta 标记的 text part)
   组装最终消息
-    content = 原始 prompt
+    content = [...hook parts, ...原始 prompt]               # 一次 append 落盘
  • PromptSubmitContext 增加 hookParts 可变插槽(钩子的唯一输出通道,与既有 block 字段同模式);hook part 固定形状 { type:'text', text:'<hook_result …>…</hook_result>', meta:{ contentType:'text/xml', source:'user prompt submit hook' } },多钩子按返回顺序各成一个 part。
  • 附带修复:stdout 为 JSON 且无 message 字段(如 {"continue":true})不再注入任何内容(此前原文注入,每轮都有噪声);纯文本 stdout 仍兜底注入。

3. Turn 层记录保留完整数据(vis/replay 可见)

turn.prompt wire 记录含完整 hook 内容(持久化、进上下文);TurnStarted 事件不落 wire,其 prompt 在构造点按 meta 剥离 hook part——公开客户端拿到干净文本,持久化与上下文语义不受影响。

4. 展示层可见渲染 / 干净摘要

  • transcript 冷 fold:hook part 映射为 hook marker(载荷与 live hook.result marker 同形),轮 prompt 提取/steer 匹配仍按剥离后内容加工,不产生 0 步轮组;
  • transcript live 投影:轮标题即 turn.started.prompt(构造点已按 meta 剥离,投影不做任何字符串加工,用户手敲相同 wrapper 开头原样保留);
  • TUI replay:meta hook part 渲染为与老 wire 相同的 hook 条目,prompt 文本干净;
  • VS Code replay:TurnBegin.user_input 剥 hook part,hook 正文以 StepBegin 后的 ContentPart 可见渲染(webview reducer 要求 step 存在,否则丢弃);
  • TUI /export-md 与 VS Code /export 的 markdown、VS Code /import <session> 的会话文本:均剥 hook part,内部协议 XML 不再进入导出文件或被再注入其他会话;
  • undo 后的 lastPrompt 重算(session 列表摘要)剥 hook part,与 title/fork 路径对齐;
  • 存量 wire 的独立 hook 消息按原逻辑继续处理,两制并存。

5. skill bundled 块统一 meta 化,消除位置切片

skill 块与 hook part 一样在构造时携带 meta: { source: 'skill activation', activationId }(fallback 补标时按 origin 顺序对应一并回填 activationId,新老 wire 的 skill part 均自描述)。原先全部 content.slice(skillActivations.length) 位置切片点——轮 prompt 文本、steer 合并与 echo、会话标题与 fork 标题、transcript fold、TUI replay——统一改为「先 annotate 再按 meta 过滤」:annotate 是集中的向后兼容层,老 wire 里无标记的前 N 个块(每个 bundled activation 一个)在读取时补标,之后与有标记的新 wire 走同一条过滤路径。prompt 内容不再依赖块位置,loop 也无需识别 hook part。

Behavior Changes and Affected Users

Behavior Before After Who relies on the old behavior Escape hatch
UserPromptSubmit 注入文本的上下文形态 独立 user 消息(origin: hook_result),位于真实 prompt 之前 并入 prompt 消息 content[0],带 meta 标记 无合理依赖(旧形态正是错位根因) 无(本 PR 即修复)
Hook stdout 为无 message 的 JSON(如 {"continue":true}) 原文注入上下文(噪声) 不注入任何内容 依赖裸 JSON stdout 注入向模型传数据的 hook 脚本 改用 {"message": "..."}
Hook stdout 为纯文本(非 JSON) 兜底注入 仍注入(现为 prompt content part) — —
turn.prompt 记录 / turn.started 事件的 prompt 均不含 hook 内容 turn.prompt 记录含完整 hook 内容(持久化、进上下文);turn.started 事件的 prompt 在构造点按 meta 剥离 hook part(事件不落 wire,持久化不受影响) 无(消费 turn.started.prompt 的客户端——ACP、desktop、SDK——拿到的是干净文本) —
skill bundled 块的 wire 形态 无标记 text part,靠 origin.skillActivations.length 位置识别 构造时携带 meta: { source: 'skill activation', activationId }(含 steer 合并消息;老 wire 读取时由 fallback 回填) 按「数量 + 位置」解析 skill 块的外部消费者(如直接读 wire 的脚本) 老 wire 由读取侧集中 fallback 补标,行为不变
会话标题 / fork 标题 / undo 后 lastPrompt 仅排除 skill 块(hook 文本可能混入) 同时排除 skill 块与 hook part — —
VS Code resume/export/import、TUI export hook XML 随 user 消息原文出现(合并后回归) 各路径剥 hook part,导出/注入文本干净;replay 中 hook 注入可见渲染 — —
多个 UserPromptSubmit 钩子均返回文本时的 live 展示 拼接为单个 hook.result 事件(一张卡) 每个钩子返回各成一个 hook.result 事件(各成一张卡),与重开后 transcript 的逐 part marker 一致 依赖「每轮至多一个 hook.result 事件」的客户端自动化 无(单钩子场景不变;仅展示粒度变化)
LLM 可见内容 hook 文本在独立消息中,位于 prompt 前 hook 文本在同一 user 消息 content[0],位于原始内容前 无(注入文本总量不变,位置语义不变) —
存量会话(含独立 hook_result 消息、无 meta 的 skill 块的旧 wire) 按原逻辑恢复/渲染 不变(两制并存,读取侧集中 fallback) 老会话 resume、搜索索引 无需

受影响模块与测试覆盖(测试数净增为 0,均为扩展既有用例):

  • 合并与记录(agent-core-v2 gate / 钩子服务):promptService.test.ts 的 gate 用例钉住单条 user 消息、content[0] meta 形状、turn.prompt 记录含 hook 内容、TurnStarted.prompt 构造点剥离为干净文本、skill 块不进 prompt 文本;
  • 注入协议(runHook / userPrompt):runner.test.ts 钉住无 message JSON(含 {"continue":true})不注入、纯文本 stdout 产出带约定 meta 的 part;
  • skill meta 与 fallback(origin.ts / steer 合并):promptService.test.ts 的 steer/queue 用例与 machine.test.ts 钉住标记与未标记块两种形态的合并/echo 结果;
  • transcript fold/steer 与 live 投影(transcript、kap-server):layers.test.ts 钉住 hook marker 可见、bundled 两形态一致、steer content-key 匹配;transcript.test.ts 钉住轮标题 meta 级剥离;
  • TUI replay 与 export(apps/kimi-code):message-replay.test.ts、kimi-tui-message-flow.test.ts、export-markdown.test.ts 钉住 hook 条目可见、prompt/导出文本干净;
  • VS Code replay/export/import(apps/vscode):replay-adapter.test.ts 钉住 TurnBegin 干净、hook 经 StepBegin 后 ContentPart 可见;
  • undo lastPrompt(agent-core-v2):undo.test.ts 钉住锚点与 pending 两分支剥 hook part;
  • 全量测试(m0-dev):898 文件 / 16662 用例全部通过。

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue (external PRs: issue must have a maintainer's /approve).
  • I have added tests that prove my feature works.(按仓库规则净增为 0:扩展既有用例覆盖全部新行为)
  • The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR.(被移除的裸 JSON 注入行为及退路见上表;changeset 聚焦用户可感的错位修复)
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.(state-manifest.d.ts 已随类型同步重新生成)

…tent parts

UserPromptSubmit hook messages now merge into the user prompt message as
meta-marked text content parts (contentType text/xml, source 'user prompt
submit hook') at content position 0 instead of a standalone hook_result
user message, so one prompt always yields exactly one user message and
turn pairing can no longer shift. Structured hook JSON without a message
field (e.g. {"continue":true}) no longer injects anything; plain-text
stdout still falls through. turn.prompt records, TurnStarted prompts,
fold turn openings, and steer content keys all strip hook parts; existing
wires with standalone hook_result messages keep their current behavior.
@changeset-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1941130

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@1941130
npx https://pkg.pr.new/@moonshot-ai/kimi-code@1941130

commit: 1941130

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ec03c39a97

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +604 to +605
...ctx.hookParts,
...gateImageFormatParts(promptMessage.content, this.profile.getModelProviderType()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude injected parts from active prompt projections

When a UserPromptSubmit hook returns text, this prepends a meta-marked part to the active prompt. The /sessions/{session_id}/prompts projection still forwards that active message verbatim (and, for promptWithSkills, uses slice(skillActivations.length)), while projectPromptContentParts drops the meta. Consequently REST/transcript prompt consumers display the internal <hook_result> XML—and bundled prompts also expose a rendered skill block—instead of the caller’s submitted content; they have no metadata left to filter it. Strip hook and skill parts before those projections.

AGENTS.md reference: AGENTS.md:L100-L100

Useful? React with 👍 / 👎.

Comment on lines +56 to 58
const text = annotateBundledSkillParts(input, bundledActivations)
.filter((part): part is TextPart => part.type === 'text' && !isSkillActivationPart(part))
.map((part) => part.text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep hook output out of TurnStarted prompts

When a UserPromptSubmit hook returns output, runPromptGate prepends it to the prompt and this helper removes only skill parts. TurnStarted.prompt therefore contains the hook's <hook_result> alongside the caller input; AgentExternalHooksService.notifyTurnStarted forwards that value as the next TurnStarted hook's inputData.prompt. Hook scripts that inspect, log, or match submitted prompts now receive their own prior hook output rather than the caller prompt. Filter isUserPromptSubmitHookPart here as well.

AGENTS.md reference: AGENTS.md:L100-L100

Useful? React with 👍 / 👎.

prompt: isDisplayablePromptOrigin(prompt.origin)
? turnPromptText(prompt.message.content, prompt.origin)
: undefined,
promptContent: isDisplayablePromptOrigin(prompt.origin) ? prompt.message.content : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip promptContent from public turn events

For every displayable prompt, this adds the full internal ContentPart[] to turn.started. Both public bridges strip only promptAttachments (packages/node-sdk/src/v2/event-mapper.ts and packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts), so promptContent is serialized to SDK and WebSocket clients even though it is absent from turnStartedEventSchema and the established wire field set. This duplicates potentially large data-URL media in durable event traffic and exposes internal hook markup and metadata; remove promptContent alongside promptAttachments in those bridges.

AGENTS.md reference: AGENTS.md:L112-L112

Useful? React with 👍 / 👎.

Comment on lines +604 to +605
...ctx.hookParts,
...gateImageFormatParts(promptMessage.content, this.profile.getModelProviderType()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide hook parts from message-history responses

When a UserPromptSubmit hook emits text, this makes its <hook_result> a part of the durable user message. buildProtocolContent maps every user part to public MessageContent while dropping meta, and both GET /sessions/{session_id}/messages and the session message pagination call toProtocolMessage. Those responses therefore expose raw hook protocol XML inside the user's message with no marker left for clients to distinguish it, whereas the old standalone message carried origin: hook_result. Filter or separately project these parts before converting message history to the REST contract.

AGENTS.md reference: AGENTS.md:L112-L112

Useful? React with 👍 / 👎.

@sailist
sailist force-pushed the feat-273-09-28-hook-result-prompt-part branch 3 times, most recently from 57d0eed to 3f77226 Compare September 29, 2026 07:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f7722633c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1213 to +1216
prompt: isDisplayablePromptOrigin(prompt.origin)
? turnPromptText(prompt.message.content, prompt.origin)
: undefined,
promptContent: isDisplayablePromptOrigin(prompt.origin) ? prompt.message.content : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep hook text out of public turn prompts

When a UserPromptSubmit hook returns text, this computes TurnStarted.prompt from the injected hook part as well as the caller input. Both public bridges then remove promptContent (packages/node-sdk/src/v2/event-mapper.ts:69-75 and packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts:966-972), leaving SDK and WebSocket consumers with no metadata to distinguish the internal <hook_result> XML from submitted text. Clients that display or log turn.started.prompt as the user's input will now expose hook output; filter hook parts before assigning the public prompt.

AGENTS.md reference: AGENTS.md:L110-L112

Useful? React with 👍 / 👎.

@sailist
sailist force-pushed the feat-273-09-28-hook-result-prompt-part branch 2 times, most recently from 63a91ea to bf5f5fe Compare September 29, 2026 08:16

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf5f5fe912

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +362 to +366
content: [
...message.content.filter(
(part) => isUserPromptSubmitHookPart(part) || isSkillActivationPart(part),
),
{ type: 'text', text },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid duplicating preserved parts during compaction

When full compaction truncates or splits a long prompt containing a UserPromptSubmit part (or a bundled skill block), extractText(message.content) has already included that text in the truncated payload. This new filter then retains the same marked part before appending the payload, so the compacted head/boundary message sends the hook or skill content to the model twice and can exceed the intended token budget; exclude retained parts from the replacement text or avoid preserving them in the prefix.

Useful? React with 👍 / 👎.

@sailist
sailist force-pushed the feat-273-09-28-hook-result-prompt-part branch from bf5f5fe to 63bdfcb Compare September 29, 2026 08:34
…onal slicing

Bundled skill blocks are now constructed with meta { source: 'skill
activation' }, matching the hook-part convention. Every former
content.slice(skillActivations.length) site — turn prompt text, steer
merge/echo, session and fork titles, transcript fold, TUI replay — now
annotates then filters by meta, so prompt content no longer depends on
block positions and the loop needs no hook-part knowledge. A shared
annotate step keeps legacy wires working: leading unmarked blocks (one
per bundled activation) are marked on read before filtering.
@sailist
sailist force-pushed the feat-273-09-28-hook-result-prompt-part branch from 63bdfcb to 1941130 Compare September 29, 2026 08:37
@sailist
sailist merged commit 06ebfc8 into main Sep 29, 2026
15 checks passed
@sailist
sailist deleted the feat-273-09-28-hook-result-prompt-part branch September 29, 2026 09:24
7723qqq pushed a commit to 7723qqq/kimi-code that referenced this pull request Oct 5, 2026
Step 11, the tail of the sync: 13 upstream commits over 177 files. The
step lands the WaitFor steering rework, the workspace-trust summary, the
sticky user messages, and the completion-budget cleanup.

Upstream's changes in this step, all absorbed:
- interrupt tower wake turns on user input and harden tower operations
  (MoonshotAI#3998)
- cap WaitFor at 90s and let new input end the wait (MoonshotAI#4061)
- summarize workspace trust with configuration sources (MoonshotAI#4056)
- trust the workspace via KIMI_CODE_TRUST_WORKSPACE (MoonshotAI#4059)
- add sticky user messages in fullscreen mode (MoonshotAI#4031)
- omit the completion token cap unless explicitly configured (MoonshotAI#4091)
- clear inherited cron tasks at the fork record (MoonshotAI#4083)
- fold UserPromptSubmit hook output into the prompt message (MoonshotAI#4081)
- remove the interruption reminder on undo of its turn (MoonshotAI#4076)
- publish permission mode changes on agent.status.updated (MoonshotAI#4057)
- stop NotifyUser nudges in hosts without the update panel (MoonshotAI#4054)
- add regression and user-impact review to the PR workflow (MoonshotAI#4023)
- sync the 2.1.1 changelog into the release notes (MoonshotAI#4018)

Fork behaviour that had to survive:
- the message-dispatch controller split: upstream's new steering path
  (the steering set, the queue-wide steer, the settle callback, the
  transcript rollback on a failed steer) was ported into
  MessageDispatchController, and KimiTUI keeps delegating to it
- the i18n layer: the rewritten trust prompt goes through `t()` with new
  en/zh entries, and the Code Review Rules section lands in DEVELOP.md
  (the fork's renamed AGENTS.md)
- the fork's node-local → host rename, its sixel graphics work in pi-tui,
  and its Windows-safe test cleanup stay in place
- the fork's tool list: the recorded tools-snapshot hashes keep the
  fork's own tool set
- the fork's path-image ingestion no longer defers the media-free submit
  path: extractMediaAttachments returns synchronously when the text
  carries no image file path, so a plain prompt reaches dispatch in the
  same tick again (upstream's steering tests assert exactly that)

Verified: typecheck clean across every package and app; full suite
17879 passed / 0 failed / 81 skipped / 1 todo, plus kimi-inspect's own
115 tests; lint 0 errors.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] UserPromptSubmit 钩子注入 message 导致 Web UI 消息时序错乱(第 n 条回复被渲染到第 n-1 条回复的位置)

2 participants